Skip to content

feat: multi round-trip requests — neutral contract and client auto-fulfilment engine#2313

Merged
felixweinberger merged 2 commits into
v2-2026-07-28from
fweinberger/mrtr-neutral-contract-engine
Jun 18, 2026
Merged

feat: multi round-trip requests — neutral contract and client auto-fulfilment engine#2313
felixweinberger merged 2 commits into
v2-2026-07-28from
fweinberger/mrtr-neutral-contract-engine

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

This adds the client half of multi round-trip requests (SEP-2322, protocol revision 2026-07-28): the neutral
InputRequest/InputResponse/InputRequiredResult types, the inputRequired() builder family, and a client engine that
fulfils input_required results automatically through the already-registered elicitation/sampling/roots handlers and
retries the original call with inputResponses, a byte-exact requestState echo, and a fresh request id.

Motivation and Context

The 2026-07-28 draft removes the server→client JSON-RPC request channel; servers obtain client input in-band by
answering tools/call, prompts/get, or resources/read with an input_required result. Clients need a way to fulfil
those results without changing their call sites.

How Has This Been Tested?

  • Unit suites for the builder, the driver loop (round cap, pacing, total-timeout bound, retry params, linked sibling
    abort, abortable pacing sleep, the per-leg options whitelist), the manual path, the inbound inputResponses
    partition, and the synthesized embedded dispatch context.
  • A client-engine suite driving a scripted 2026-era server over an in-memory transport (auto-fulfilment, multi-round
    flows, missing-handler/unknown-kind failures, manual mode, the synthesized handler context).
  • The spec-type parity and example-corpus suites cover the multi-round-trip wire vocabulary.
  • Full repo gates: typecheck, lint, docs, all package suites, the e2e matrix, and the integration suite.

Breaking Changes

None for 2025-era traffic. On 2026-07-28 connections, input_required answers are now fulfilled automatically by
default; inputRequired: { autoFulfill: false } and the per-call allowInputRequired option restore manual handling.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

  • client.callTool() keeps returning a plain CallToolResult; the interactive rounds happen inside the call.
  • The shared Protocol base stays generic: it carries only the input_required branch in the response path, a single
    named protected extension point _resolveNonCompleteResult(decoded, flow) (base default = the typed
    UnsupportedResultType error), and the type surface; the engine body lives in shared/inputRequiredEngine.ts and
    the pure loop in shared/inputRequiredDriver.ts. Client owns ClientOptions.inputRequired and overrides the hook.
  • Round cap defaults to 10 (aligned with the other SDK client engines); retry-leg options are a deliberate whitelist
    (resumption tokens never carry over); embedded sibling dispatches share a linked abort.
  • requestState is treated as an opaque string end to end (echoed byte-exact, never parsed).

@felixweinberger felixweinberger requested a review from a team as a code owner June 16, 2026 21:11
@changeset-bot

changeset-bot Bot commented Jun 16, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dbb2a08

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/core Minor
@modelcontextprotocol/client Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 16, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@2313

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/codemod@2313

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@2313

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server-legacy@2313

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@2313

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/fastify@2313

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@2313

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@2313

commit: dbb2a08

Comment thread packages/client/src/client/client.ts
Comment thread packages/core/src/shared/inputRequired.ts
Comment thread packages/core/src/shared/inputRequiredDriver.ts Outdated
Comment thread packages/core/src/shared/protocol.ts Outdated
Comment thread packages/core/src/wire/rev2026-07-28/codec.ts
…wire vocabulary

The neutral InputRequest/InputResponse/InputRequiredResult types and the
HandlerResultTypeMap; the inputRequired() builder family with its per-kind
constructors, acceptedContent(), and withInputRequired(); the
isInputRequiredResult() guard; the 2026-07-28 in-band wire schemas and the
WireCodec.inputRequestSchema/inputResponseSchema accessors; spec-type parity
checks and the SEP-2322 corpus pending-entry burn-down.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-neutral-contract-engine branch from e461f95 to fbb3dbb Compare June 18, 2026 12:39
The client half of multi round-trip requests, factored so the shared
`Protocol` base stays generic:

- protocol.ts gains only the irreducible input-required branch in the
  response path (manual mode via `allowInputRequired` plus a single named
  protected extension point `_resolveNonCompleteResult(decoded, flow)`
  whose base default is the existing typed UnsupportedResultType error),
  the type surface (`RequestOptions.allowInputRequired`, the
  `inputResponses`/`droppedInputResponseKeys`/`requestState` context
  fields), the `HandlerResultTypeMap` handler-return typing, and a
  `_getRequestHandler` accessor.
- the engine body (driver invocation, embedded-request dispatch,
  synthesized-ctx construction, the inputResponses partition, and the
  per-retry-leg options whitelist) lives in
  `shared/inputRequiredEngine.ts`; the pure driver loop stays in
  `shared/inputRequiredDriver.ts`.
- `Client` owns `ClientOptions.inputRequired` and overrides the hook to
  wire the engine.

Includes three driver hardening fixes: each retry leg's request options
are a whitelist (resumptionToken/onresumptiontoken/relatedRequestId never
carry over); embedded sibling dispatches share a linked per-round abort
so one failure cancels the others; the requestState-only pacing sleep
honors the caller's abort signal.
@felixweinberger felixweinberger force-pushed the fweinberger/mrtr-neutral-contract-engine branch from fbb3dbb to dbb2a08 Compare June 18, 2026 12:45
Comment on lines +262 to +276
...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout })
};
if (requestOptions.maxTotalTimeout !== undefined) {
const totalElapsed = Date.now() - startedAt;
const remaining = requestOptions.maxTotalTimeout - totalElapsed;
if (remaining <= 0) {
throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
maxTotalTimeout: requestOptions.maxTotalTimeout,
totalElapsed
});
}
legOptions.maxTotalTimeout = remaining;
}

const result = await hooks.retry(buildInputRequiredRetryParams(originalParams, responses, payload.requestState), legOptions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 When the maxTotalTimeout budget is already exhausted at the top of a round (e.g. the first wire leg alone overran it), runInputRequiredDriver still dispatches the round's embedded elicitation/sampling handlers — potentially showing a user-facing prompt — and only afterwards computes remaining <= 0 and throws RequestTimeout, discarding the answer. Performing the same budget check at the top of the loop, before dispatching handlers, avoids interactive work whose result is guaranteed to be thrown away.

Extended reasoning...

What the bug is

In runInputRequiredDriver (packages/core/src/shared/inputRequiredDriver.ts), the maxTotalTimeout accounting — totalElapsed = Date.now() - startedAt, remaining = maxTotalTimeout - totalElapsed, throw RequestTimeout when remaining <= 0 — sits after the round's dispatch block (the Promise.all over hooks.dispatchInputRequest, or the requestState-only pacing sleep) and immediately before hooks.retry(). Nothing checks the budget at the top of a round. So when the whole-flow budget is already exhausted before the round begins, the driver still dispatches the embedded elicitation/create / sampling/createMessage handlers — for elicitation that means showing a user-facing prompt and waiting for the user's answer — and only then throws RequestTimeout, throwing the answer away.

The code path that triggers it

The trigger is realistic, not test-only. The funnel does not enforce maxTotalTimeout with a wall-clock timer on a wire leg: in protocol.ts the only per-leg timer is the per-leg timeout (_setupTimeout), and maxTotalTimeout is consulted only inside _resetTimeout, which runs solely when resetTimeoutOnProgress is enabled and a progress notification arrives. With { timeout: 60_000, maxTotalTimeout: 5_000 } — exactly the combination in the PR's own engine test "counts the first wire leg against maxTotalTimeout (the budget bounds the whole flow)" — the first wire leg can legally take 10s and still resolve with input_required. The driver then enters round 1, immediately dispatches the embedded requests to the registered handlers, and only after the handlers settle does it discover the budget was gone before any of that work started. The same shape recurs on later rounds whose preceding retry leg overran its (shrunken) budget without emitting progress.

Why existing code doesn't prevent it

The check before hooks.retry() correctly prevents a doomed retry leg from reaching the wire (the driver test "the total-timeout budget is measured from the flow start" pins that), but there is no symmetric check before the dispatch block. flowStartedAt is threaded in precisely so the first leg counts against the budget, yet the first place that accounting is consulted is after the round's interactive work has already run.

Step-by-step proof (mirrors the PR's own client test)

  1. client.callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 }) is issued at t=0; flowStartedAt = 0.
  2. The server takes 10s and answers with input_required carrying an elicitation/create entry. The per-leg timeout (60s) never fires, and maxTotalTimeout is not enforced on a completed leg, so the funnel hands the payload to the driver at t=10s — 5s past the whole-flow budget.
  3. The driver enters round 1 and calls Promise.all([... dispatchInputRequest('github_login', ...)]): the registered elicitation handler runs — in a real client this is a prompt shown to the user, who types an answer.
  4. Only after the handler resolves does the driver compute remaining = 5_000 - (Date.now() - 0) <= 0 and throw SdkError(RequestTimeout, { maxTotalTimeout: 5_000 }). The user's answer is discarded; toolCalls stays at 1 (as the test asserts).
  5. The budget was provably exhausted before step 3 began, so the interactive work was guaranteed to be wasted.

Impact

No correctness impact on the final outcome — the call still rejects with the right typed RequestTimeout error, there is no leak or state corruption, and a budget that expires mid-prompt cannot be helped regardless. The cost is a pointless user-facing prompt (or sampling handler invocation) whose result is always thrown away, which contradicts the documented intent that "the budget bounds the WHOLE flow". Hence nit severity.

How to fix

Hoist the same remaining <= 0 computation to the top of the while loop (before the dispatch / pacing branch), in addition to keeping it before the retry leg. A small refactor — e.g. a local remainingBudget() helper called at both points — keeps the two checks from drifting.

Comment on lines +561 to +576
/**
* The handler-return counterpart of {@linkcode ResultTypeMap}: what a
* registered request handler may RETURN for each method. Identical to
* `ResultTypeMap` except that the multi-round-trip methods (`tools/call`,
* `prompts/get`, `resources/read`) additionally accept an
* {@linkcode InputRequiredResult} (protocol revision 2026-07-28).
*
* `ResultTypeMap` itself — what a *requester* receives — is deliberately NOT
* widened: `client.callTool()` returns a plain {@linkcode CallToolResult} on
* both protocol eras.
*/
export type HandlerResultTypeMap = {
[M in keyof ResultTypeMap]: M extends 'tools/call' | 'prompts/get' | 'resources/read'
? ResultTypeMap[M] | InputRequiredResult
: ResultTypeMap[M];
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The documented manual retry path can't be expressed through the typed surface: the changeset and RequestOptions.allowInputRequired's JSDoc tell callers to retry the original request with inputResponses/requestState params, but CallToolRequest['params'] / GetPromptRequest['params'] / ReadResourceRequest['params'] were not widened with those members (only the 2026 wire schemas gained retryParamsShape), so the PR's own manual-mode test has to cast with as Parameters<Client['callTool']>[0]. Consider a typed retry-params widening for the three MRTR methods (mirroring HandlerResultTypeMap on the requester side) or a dedicated typed retry helper.

Extended reasoning...

What the bug is

This PR documents a manual multi-round-trip flow: when a call is made with allowInputRequired: true (after inputRequired: { autoFulfill: false }), the input_required value is handed back and — per the RequestOptions.allowInputRequired JSDoc in protocol.ts and the changeset — "the caller is then responsible for gathering the requested input and retrying the original request with inputResponses / requestState params and a fresh request." That retry, however, cannot be written through the typed public surface: the neutral request param types the client methods accept (CallToolRequest['params'], GetPromptRequest['params'], ReadResourceRequest['params'] from packages/core/src/types/types.ts) have no inputResponses or requestState members, so passing them is an excess-property compile error.

The specific code path

CallToolRequestParamsSchema in packages/core/src/types/schemas.ts is TaskAugmentedRequestParamsSchema.extend({ name, arguments? }) built on a plain z.object (no catchall / index signature), so the inferred CallToolRequest['params'] has only _meta/task/name/arguments. The same holds for prompts/get and resources/read. The retry channel was added only to the 2026 wire-true schemas (retryParamsShape in wire/rev2026-07-28/schemas.ts), and those never reach the public method types — client.callTool() and the method-keyed client.request() overload (RequestTypeMap['tools/call']) are typed from the neutral schemas.

Why nothing else prevents it / proof

The PR's own manual-mode test demonstrates the gap. In packages/client/test/client/inputRequiredEngine.test.ts ("allowInputRequired: true hands the input-required value back to the caller, who can retry manually") the retry has to be written as:

const second = await client.callTool({
    name: 'deploy',
    arguments: {},
    inputResponses: { github_login: { action: 'accept', content: { name: 'octocat' } } },
    requestState: first.requestState as string
} as Parameters<Client['callTool']>[0]);

Step-by-step: (1) a consumer follows the documented manual flow and writes the same object literal without the cast; (2) TypeScript checks it against CallToolRequest['params'], which has no inputResponses/requestState members; (3) excess-property checking rejects the literal — compile error; (4) the only ways forward are an unsafe as cast (what the SDK's own test does) or routing through an untyped path. At runtime everything works (the 2026 wire schema accepts the fields and they reach the wire, as the test shows), so this is purely a type-surface/DX gap — but it is a gap on the exact public flow this PR documents.

Why this is worth addressing

The handler-return side received a deliberate typed widening in this same PR (HandlerResultTypeMap, which adds InputRequiredResult to the three MRTR methods' return types), so the asymmetry is conspicuous: the documented requester-side retry got no equivalent treatment. That the SDK's own test must cast through Parameters<Client['callTool']>[0] is strong evidence consumers will hit the same wall.

How to fix

Either (a) widen the requester-side params for the three MRTR methods — e.g. a RetryAugmented<P> = P & { inputResponses?: InputResponses; requestState?: string } applied to tools/call / prompts/get / resources/read in the request type map and the callTool/getPrompt/readResource signatures, mirroring how HandlerResultTypeMap widened only the affected methods — or (b) ship a small typed retry helper (e.g. withRetryParams(params, { inputResponses, requestState })) so manual-mode callers never need an as cast. No runtime change is needed; this only affects the auto-fulfilment opt-out path.

Comment on lines +126 to +145
export function buildInputRequiredRetryParams(
originalParams: Record<string, unknown> | undefined,
responses: Record<string, unknown> | undefined,
requestState: string | undefined
): Record<string, unknown> | undefined {
const hasResponses = responses !== undefined && Object.keys(responses).length > 0;
if (!hasResponses && requestState === undefined) {
return originalParams;
}
return {
...originalParams,
...(hasResponses && { inputResponses: responses }),
// Byte-exact echo: the opaque string is copied verbatim, never parsed.
// When the result carried no requestState, the retry carries none.
...(requestState !== undefined && { requestState })
};
}

/**
* Abortable delay: resolves after `ms`, or rejects with the signal's reason

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 buildInputRequiredRetryParams spreads originalParams without stripping any pre-existing inputResponses/requestState keys, so when the originating call's params already carried them (the documented manual-retry shape: one allowInputRequired round, then a manual retry with { ...args, inputResponses, requestState } that the auto-fulfilment driver picks up), stale values leak into the driver's retries — a requestState-only round re-sends the old inputResponses verbatim, and a round whose payload carries no requestState still echoes the previous one, contradicting the inline comment 'When the result carried no requestState, the retry carries none'. Fix by stripping inputResponses/requestState from originalParams before spreading.

Extended reasoning...

What the bug is

buildInputRequiredRetryParams (packages/core/src/shared/inputRequiredDriver.ts:126-142) builds every retry leg as:

return {
    ...originalParams,
    ...(hasResponses && { inputResponses: responses }),
    // Byte-exact echo: ... When the result carried no requestState, the retry carries none.
    ...(requestState !== undefined && { requestState })
};

originalParams is flow.request.params passed verbatim by runInputRequiredFlow (inputRequiredEngine.ts), and the spread only overrides inputResponses when this round produced responses and requestState when this round's payload carried one. Nothing ever strips retry-channel members that were already present on the original params. So when the originating call itself carried inputResponses/requestState, those stale values survive into every driver retry that does not explicitly replace them.

How the trigger is reachable through documented API

The per-call allowInputRequired: true option is honored in protocol.ts before _resolveNonCompleteResult, regardless of the instance-level autoFulfill setting. So on a default (autoFulfill: true) client a caller can take the first round manually — exactly the shape the PR's own manual-mode test demonstrates: callTool({ ...args, inputResponses, requestState }) with the responses and echo placed directly into the params — and then issue that retry without allowInputRequired. If the server answers that retry with another input_required, the auto-fulfilment driver takes over with originalParams that already contain inputResponses and requestState from the previous (already-consumed) round.

What goes wrong

  1. Stale inputResponses on a requestState-only round. When a later round carries only requestState (no embedded requests), responses is undefined, so the inputResponses override is skipped — and the spread re-sends the stale inputResponses from the manual round, keyed to entries the server already consumed.
  2. Stale requestState when the new payload carries none. When a round's input_required carries no requestState, requestState is undefined, so the override is skipped — and the previous round's requestState from originalParams is echoed anyway. This directly contradicts the inline contract comment on line 139 ('When the result carried no requestState, the retry carries none') and the byte-exact-echo-of-the-LAST-result semantics. A server that integrity-protects requestState (HMAC/AEAD, as the PR's own docs recommend) and binds it to a round would reject the stale echo, producing confusing failures.

Step-by-step proof

  1. Client on default config (autoFulfill: true) calls callTool({ name: 'deploy' }, { allowInputRequired: true }); server answers input_required with requestState: 'r1' and an elicit entry.
  2. Caller fulfils it manually and retries callTool({ name: 'deploy', inputResponses: { confirm: {...} }, requestState: 'r1' }) — no allowInputRequired this time.
  3. Server answers this retry with another input_required, this time carrying only inputRequests (no requestState). The driver runs with originalParams = { name: 'deploy', inputResponses: { confirm: {...} }, requestState: 'r1' }.
  4. The driver fulfils the new requests and calls buildInputRequiredRetryParams(originalParams, newResponses, undefined): inputResponses is correctly replaced by newResponses, but requestState: 'r1' survives the spread and is sent — even though the last result carried no requestState.
  5. Symmetrically, if the server's second answer were requestState-only (inputRequests empty), responses would be undefined and the stale inputResponses: { confirm: {...} } from step 2 would be re-sent verbatim.

Why existing code/tests don't catch it

The pure auto-fulfilment flow never puts retry-channel members into the original params (the driver always rebuilds from clean params, and retry legs use allowInputRequired so nested driver runs never happen), and the documented autoFulfill: false manual flow never reaches the driver at all. The unit tests for buildInputRequiredRetryParams only use clean original params, so the contamination path is untested.

Impact and fix

Impact is bounded: it requires hybrid manual-then-auto usage plus a multi-round server flow, and the server must treat the retry-channel members as untrusted anyway. Still, it is a genuine contract violation in code introduced by this PR, and the fix is trivial — strip inputResponses and requestState from originalParams before spreading (or explicitly delete them when the current round carries none), so the retry always reflects exactly the LAST result.

@felixweinberger felixweinberger merged commit dab933d into v2-2026-07-28 Jun 18, 2026
19 checks passed
@felixweinberger felixweinberger deleted the fweinberger/mrtr-neutral-contract-engine branch June 18, 2026 13:03
Comment on lines +261 to +274
const legOptions: InputRequiredRetryLegOptions = {
...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout })
};
if (requestOptions.maxTotalTimeout !== undefined) {
const totalElapsed = Date.now() - startedAt;
const remaining = requestOptions.maxTotalTimeout - totalElapsed;
if (remaining <= 0) {
throw new SdkError(SdkErrorCode.RequestTimeout, 'Maximum total timeout exceeded', {
maxTotalTimeout: requestOptions.maxTotalTimeout,
totalElapsed
});
}
legOptions.maxTotalTimeout = remaining;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 On a retry leg the driver shrinks maxTotalTimeout to the remaining budget but passes the per-leg timeout through unchanged — and since the funnel only checks maxTotalTimeout inside _resetTimeout (i.e. when a progress notification arrives with resetTimeoutOnProgress), a no-progress retry leg can run for the full per-leg timeout (default 60s) even when only a few hundred milliseconds of the whole-flow budget remain, overrunning the bound documented by the new RequestOptions.maxTotalTimeout JSDoc ('the budget bounds the WHOLE flow') by up to one per-leg timeout per round. Consider clamping the per-leg timeout to the remaining budget, e.g. legOptions.timeout = Math.min(requestOptions.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC, remaining).

Extended reasoning...

What the bug is

In runInputRequiredDriver (packages/core/src/shared/inputRequiredDriver.ts:261-274), each retry leg is built as:

const legOptions: InputRequiredRetryLegOptions = {
    ...(requestOptions.timeout !== undefined && { timeout: requestOptions.timeout })
};
if (requestOptions.maxTotalTimeout !== undefined) {
    const remaining = requestOptions.maxTotalTimeout - totalElapsed;
    if (remaining <= 0) { throw RequestTimeout; }
    legOptions.maxTotalTimeout = remaining;
}

Only maxTotalTimeout is shrunk to the remaining budget; the per-leg timeout is the caller's original value, unchanged. The problem is that the shrunken maxTotalTimeout has no wall-clock enforcement of its own in the funnel: _setupTimeout (protocol.ts:620-635) arms a single setTimeout(onTimeout, timeout) for the per-leg timeout and merely stores maxTotalTimeout; the only place maxTotalTimeout is consulted is _resetTimeout (protocol.ts:637-648), which is invoked solely from the progress-notification path and only when resetTimeoutOnProgress is set (protocol.ts:1040-1042).

The code path

So with a server that emits no progress (or a caller that didn't set resetTimeoutOnProgress), the only thing bounding a retry leg is the per-leg timeout. Concretely: callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 }) — the exact option combination in the PR's own engine test. The first leg takes 3s and answers input_required; the driver computes remaining = 2_000 and sends the retry with maxTotalTimeout: 2_000 but timeout: 60_000. The server answers after 30s (with another input_required or anything else); only at the next round top does the remaining <= 0 check throw. Total elapsed ≈ 33s against a 5s budget. Across maxRounds rounds the overrun can compound.

Why existing code/tests don't catch it

The PR's tests only exercise the round-top accounting: the driver tests mock Date.now() and the retry hooks resolve instantly, and the client-engine test ('counts the first wire leg against maxTotalTimeout') advances the mocked clock between calls, so the unenforced in-leg case is never hit.

What this contradicts

This PR adds the RequestOptions.maxTotalTimeout JSDoc: 'the budget bounds the WHOLE flow: every retry leg is given only the time remaining' (protocol.ts:130-132), and the driver module header makes the same promise ('maxTotalTimeout bounds the whole flow by shrinking the budget passed to each leg'). In the no-progress case the leg is in fact given the full per-leg timeout, not the time remaining.

On the refutation (why this is a nit, not nothing)

A refuting reviewer correctly points out that the underlying limitation — maxTotalTimeout being a progress-check-only knob rather than a wall-clock timer — is pre-existing funnel behavior, that the driver explicitly rides the existing knobs by design, and that the worst-case overrun is bounded at one per-leg timeout past the budget per round before the round-top check fires. All true, and the call still eventually fails with the correct typed RequestTimeout error. But the whole-flow-bound promise (the new JSDoc and module header) and the per-leg budget shrinking are introduced by this PR, and 'shrinking the budget passed to each leg' is only meaningful if the shrunken value is actually enforced — which it isn't on the path most likely to need it (a slow, non-progressing server). The refutation's concern that clamping timeout 'silently overrides the caller's per-leg timeout' is a fair design point, but a leg's effective deadline can never legitimately exceed the remaining whole-flow budget, so min(timeout, remaining) does not take anything away the caller could have used.

Step-by-step proof

  1. client.callTool({ name: 'deploy' }, { timeout: 60_000, maxTotalTimeout: 5_000 }) at t=0; flowStartedAt = 0.
  2. The server answers at t=3s with input_required (one elicit entry). The handler resolves instantly.
  3. The driver reaches the budget block: remaining = 5_000 - 3_000 = 2_000 > 0, so legOptions = { timeout: 60_000, maxTotalTimeout: 2_000 } and the retry is sent.
  4. In _requestWithSchemaViaCodec for the retry leg, _setupTimeout(messageId, 60_000, 2_000, ...) arms only the 60s timer; maxTotalTimeout: 2_000 sits unused because the server emits no progress.
  5. The server answers the retry at t=33s with another input_required. The leg resolves normally — no timer fired.
  6. Only now does the driver's next round-top check compute remaining = 5_000 - 33_000 <= 0 and throw RequestTimeout. The 5s budget was overrun by 28s.

How to fix

Clamp the per-leg timeout to the remaining budget when one exists:

legOptions.timeout = Math.min(requestOptions.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC, remaining);

(or equivalently set legOptions.timeout = remaining only when it's smaller). This keeps the existing-knobs design — no new timer system — while making the documented whole-flow bound hold within one leg's resolution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant